Skip to content

Migrate pipeline from CircleCI to GitHub Actions#218

Merged
phelma merged 2 commits into
mainfrom
gha-migration
Jul 22, 2026
Merged

Migrate pipeline from CircleCI to GitHub Actions#218
phelma merged 2 commits into
mainfrom
gha-migration

Conversation

@phelma

@phelma phelma commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Cutover to GitHub Actions per the Variant A family plan (gem pilot).
Includes decommission — merging this PR completes the repo's migration.

  • main + pr workflows: check/test, prerelease, release environment gate
  • PR CI publishes a namespaced pre-release to RubyGems (see below)
  • git-crypt unlock on the runner; encrypted CI GPG key moved to .github/
  • Slack notifications via rake_slack; dependabot auto-merge job
  • Rakefile provisioning swapped to rake_github secrets/environments; rake_circle_ci dropped
  • CircleCI pipeline removed: .circleci/, scripts/ci/, the CI SSH deploy
    key pair and its keys:deploy/deploy_keys provisioning, and the stored
    CircleCI/GitHub API credentials (config/secrets/{circle_ci,github}/)

Deliberate decisions (not defects)

This cutover reproduces the CircleCI pipeline's behaviour, warts included;
fixing inherited hazards is post-migration work. In particular:

  • ./go release publishes to RubyGems before the version-bump commit is
    pushed — pre-existing ordering inside the untouched release logic.
  • Prerelease publishes on every push to main with no approval gate; only
    full releases are gated (environment: release).
  • Dependabot auto-merge accepts any update type that passes checks, and the
    merge does not trigger a release build — on CircleCI the merge commit
    carried [skip ci], so this matches. Updates ship with the next
    human-triggered release.
  • The release job pulls main at approval time, so a delayed approval
    publishes main as it stands then, not the SHA this run tested — parity with
    the old release.sh (which also pulled; prerelease.sh did not, so the
    prerelease job has no pull).
  • asdf_install@v1 is our own action (infrablocks/github-actions); we are
    happy tracking its major version tag.
  • Job scaffolding is repeated flat per job by design: the logic lives in the
    build system (./go/rake) and CI stays lean — it just triggers tasks and
    supplies secrets/context.
  • Gemfile.lock carries transitive major bumps — the unavoidable resolution
    of the targeted bundle lock --update, not scope creep.
  • Small library hunks may appear where the refreshed toolchain's rubocop
    autocorrects existing code (e.g. Style/ArgumentsForwarding) — required
    by the library:check verification gate, not drive-by refactoring.
  • Provisioning (pipeline:prepare) authenticates with the operator's ambient
    gh login (GITHUB_TOKEN fallback) instead of a stored PAT — a deliberate
    parity deviation; the stored token in config/secrets/github/config.yaml
    is deleted with the rest of the CircleCI-era credentials.

PR-CI prerelease publish (deliberate, permanent)

pr.yaml has a prerelease job that publishes a namespaced pre-release of
this gem to RubyGems from the PR branch — a permanent CI feature, not
migration-only. This is a deliberate deviation from CircleCI (which published
nothing pre-merge): it proves the publish path before merge instead of
discovering it broken on main. The version is
<committed-version>.pr<PR>.<run>.<attempt> (via the new prerelease:publish
Rakefile task), so it can never collide with main's version:bump[pre]
sequence; the task builds the gem and pushes it straight to RubyGems, then
restores version.rb, so nothing is committed, tagged, or pushed
(gem release is not used — it aborts on the uncommitted version rewrite).
The job is skipped for fork
and Dependabot PRs (they hold no secrets), and merge-pull-request does not
depend on it. PR pre-release versions accumulate permanently on RubyGems —
accepted.

Do not merge manually — the pipeline merges once checks are green.
Disabling the CircleCI project and deleting the CircleCI deploy key are
deferred to the end-of-migration sweep.


🏭 This PR was opened by Foundry, Atomic's AI software development
factory. Implementation, review, and fixes are performed by AI agents;
merges happen automatically once the review and checks gates pass.
This task migrates a Ruby gem's CI from CircleCI to GitHub Actions.

Pipeline Task pack Family Run
migrate migrate-gem gem 2026-07-22T15-58-21-187Z

atomic-foundry-pr · foundry-pipeline: migrate · foundry-task: migrate-gem · foundry-run: 2026-07-22T15-58-21-187Z

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #218 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: COMMENT

This is a careful, plan-conformant CircleCI→GHA cutover: the workflow YAML, Rakefile tasks, gemspec/lockfile, README and decommission deletions all match the Variant A family plan, and the security-sensitive parts (untrusted-input handling via env vars, fork/dependabot guards, environment-gated release, git-crypt ciphertext guard, per-job timeouts and concurrency) are sound. One real in-scope defect surfaced under verification: both workflows' Test jobs run ./go spec, which is a no-op — no spec rake task exists, so rake resolves it against the existing spec/ directory and exits green having run zero examples (./go test:unit runs the real 187-example suite). The test gate therefore passes without testing anything, and it gates dependabot auto-merge and release. Everything else is either plan-conformant or a documented-deliberate decision.

Cross-Cutting Themes

  • Empty test gate (correctness; reviewer-verified) — the Test job runs ./go spec (a no-op file task) instead of ./go test:unit (187 examples). Reported by the correctness lens as a hard failure; direct verification shows it does not fail — it silently runs no tests and reports success. This is the single actionable finding.

Tradeoff Analysis

  • Literal parity vs effective CI: plan §4.2 says "use the repo's own check/test task names," and the deleted test.sh did invoke ./go spec — so spec is faithful parity. But §2.3 asserts test.sh → ./go test:unit and §5 verifies the suite via test:unit. A test gate that runs zero tests (inherited from the old, equally-empty pipeline) is almost certainly not the intent. Recommend switching both Test steps to ./go test:unit.

Strengths

  • ✅ Untrusted PR values passed via env: and referenced as $VAR in run: — correct anti-injection pattern; no ${{ }} interpolation into shell.
  • prerelease (secret-bearing) job guarded to same-repo non-dependabot PRs via the immutable pull_request.user.login; uses pull_request, not pull_request_target.
  • ✅ Provisioning fails fast on a git-crypt-locked clone (checks \x00GITCRYPT) rather than uploading ciphertext; token resolution fails fast.
  • prerelease:publish restores version.rb and removes the built gem in an ensure block.
  • ✅ Every job carries timeout-minutes; prerelease/release share a main concurrency group with queue: max to serialise version-racing releases.
  • ✅ Alphabetical require/dev-dependency lists; self-contained comments (no migration/plan/D-number references); PLATFORMS keep ruby+x86_64-linux, no darwin.

General Findings

  • 🟡 Correctness/Safety: Test job runs ./go spec (no-op) — see inline comments. Verified: test:unit = 187 examples; spec = 0 examples, exit 0.
  • 🔵 Standards: .rubocop.yml adds define_repository_tasks to Metrics/BlockLength.Excluded — outside the plan's enumerated file list (see inline). Justified and harmless, but confirm intended.

Plan Concerns (documented-deliberate — not defects; recorded so a human can revisit the plan)

  • 🔵 Security: actions/checkout@v4 and asdf_install@v1 pinned to mutable tags in secret-bearing jobs — plan §1/D6 accepts tracking the moving @v1 tag; @v4 is prescribed verbatim. SHA-pinning is post-migration hardening.
  • 🔵 Security: dependabot auto-merge accepts any update type that passes checks — documented D3.
  • 🔵 Safety: main prerelease publishes to RubyGems before git push with no git pull; recovery is gem yank — documented publish-before-push parity (D5, §1).
  • 🔵 Safety: a release job pending environment approval holds the main concurrency slot, stalling later prereleases — documented tradeoff (§4.2).
  • 🔵 Code Quality: prerelease:publish's sub(pattern, "\\1#{version}\\3") backreference-interpolation, hard-coded gem filename, and duplicated gem build all sit on code prescribed near-verbatim by plan §4.4 step 8 — observations against the prescribed shape, not defects.

Review generated by /accelerator:review-pr

Comment thread .github/workflows/main.yaml Outdated
- name: Install tools
uses: infrablocks/github-actions/asdf_install@v1
- name: Test
run: ./go spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Correctness (major, reviewer-verified)

The Test job runs ./go spec (= bundle exec rake spec). This repo defines no spec task — the unit suite is test:unit (RSpec::Core::RakeTask.new(:unit) under namespace :test). Rake instead resolves spec against the existing spec/ directory as an up-to-date file task, so the step exits 0 having run zero examples.

Verified locally: bundle exec rake test:unit → "187 examples, 0 failures"; bundle exec rake spec → no examples run, exit 0.

Impact: The Test job is green but tests nothing. It gates prerelease and release (needs: [check, test]), so untested code publishes to RubyGems on every push to main. (This matches the deleted test.sh, which also ran ./go spec — an inherited empty gate — but plan §2.3/§5 intend test:unit.)

Suggestion: Change to run: ./go test:unit.

Comment thread .github/workflows/pr.yaml Outdated
- name: Install tools
uses: infrablocks/github-actions/asdf_install@v1
- name: Test
run: ./go spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Correctness (major, reviewer-verified)

Same as main.yaml: the PR Test job runs ./go spec, which resolves to a no-op file task (no spec rake task exists; spec/ is a directory) and runs zero examples while reporting success. The real suite is ./go test:unit (187 examples).

Impact: On PRs the test gate passes without testing. Both prerelease and merge-pull-request needs: [check, test, build], so dependabot auto-merge and the PR-CI prerelease publish are gated on a test job that runs nothing.

Suggestion: Change to run: ./go test:unit.

Comment thread .rubocop.yml
- context
- shared_examples
- it
- define_repository_tasks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards (suggestion)

This adds define_repository_tasks to Metrics/BlockLength.Excluded. The family plan enumerates the cutover's file changes and says "Do not restructure anything else in the Rakefile … keep it clean"; it does not authorise a .rubocop.yml change. The RakeGithub block genuinely grew past the BlockLength threshold once the mandated token/passphrase guard was inlined, so an exclusion is a defensible way to keep ./go library:check green, and it is stylistically consistent with the existing DSL exclusions.

Impact: A config change beyond the plan's prescribed scope; harmless here but worth confirming it was intended rather than accidental drift.

Suggestion: Confirm the exclusion is intended; note it as a deliberate deviation from the plan's file list.

@phelma phelma left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: #218 - Migrate pipeline from CircleCI to GitHub Actions

Verdict: COMMENT

This is a clean, plan-conformant Variant A cutover. Across correctness, security, safety, standards and code-quality lenses the diff faithfully re-expresses the CircleCI skeleton as GitHub Actions, with unusually careful security hygiene (attacker-influenced values passed via env: not ${{ }}, --match-head-commit on the auto-merge, secret-bearing jobs fenced to same-repo human PRs, fail-fast provisioning guards). No in-scope defects were found. Almost every finding raised by the lenses challenges a decision the family plan or PR description documents as deliberate — these are recorded below as plan concerns for a human to weigh against the plan, and do not block the diff. Two lens findings were disproven and are noted as false positives.

False Positives (disproven — not counted)

  • 🔴→✅ queue: max is "invalid GitHub Actions syntax" (flagged by correctness ×2 and safety). The concurrency: { queue: max } key on main.yaml's prerelease/release jobs was flagged as a non-existent option that would fail to parse or drop queued runs. This is a knowledge-cutoff artefact: queue: max is a real GitHub Actions concurrency option that reached GA on 2026-05-07 (https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/), after the reviewers' training cutoff. The plan relies on it deliberately. Not a defect.

Plan Concerns (documented-deliberate — non-blocking)

Each targets something the plan or PR description records as intended, so none block this plan-conformant diff.

  • 🟡 Same-repo PR prerelease job exposes the full publishing secret chain to PR-branch code (security) — documented D8; no environment gate. Worth revisiting whether the PR publish should sit behind a reviewed environment or a push-only RubyGems key.
  • 🟡 Dependabot auto-merge may run with a read-only GITHUB_TOKEN (correctness) — worth a human verifying the job-level permissions reliably elevates secrets.GITHUB_TOKEN on the pull_request event; mechanism is exactly what D3 prescribes.
  • 🔵 Dependabot auto-merge accepts any update type (security) — §1 parity / D3.
  • 🔵 ./go release publishes to RubyGems before pushing the version-bump commit/tag (safety) — §1 parity / D5 inherited hazard.
  • 🔵 Committed encrypted GPG key concentrates release trust in one secret (security) — D1; worth rotating the passphrase/GPG key at CircleCI decommission.
  • 🔵 PR-CI prereleases accumulate irreversibly on public RubyGems (safety) — accepted in D8.
  • 🔵 define_repository_tasks BlockLength rubocop exemption diverges from the sibling helper-extraction convention (standards) — the plan prescribes the inline shape verbatim (§4.4 step 3).
  • 🔵 gh auth token fallback only rescues Errno::ENOENT (code-quality) — plan-verbatim §4.4 step 3.
  • 🔵 prerelease:publish String#sub replacement assumes numeric args (correctness) — safe under current data flow; plan-verbatim §4.4 step 8.
  • 🔵 gem build duplicated between library:build and prerelease:publish (code-quality) — both plan-verbatim.
  • 🔵 Slack channel IDs are opaque repeated literals (code-quality) — plan-verbatim §4.4 step 4.

Strengths

  • ✅ Systematic script-injection avoidance: only the trusted job.status enum is interpolated into run:; PR title/URL/number and run metadata flow through env:.
  • gh pr merge --match-head-commit "$HEAD_SHA" closes the merge TOCTOU.
  • prerelease:publish restores version.rb and removes the built gem in an ensure block.
  • ✅ Provisioning fails fast on a missing passphrase and refuses to upload git-crypt ciphertext as a secret.
  • ✅ Least-privilege token model; release gated behind a reviewed environment.
  • ✅ Workflow YAML matches the established sibling migration; requires/dependencies stay alphabetised.

Plan-conformance (independent §4 check)

Both directions verified. All §4 changes are present and correct; the release job's version:bump[minor] correctly mirrors the repo's old release.sh (a parameterisation point, not the template's [patch]), and documentation:update is correctly omitted. Nothing changed beyond the plan except the benign .rubocop.yml BlockLength exemption. No plan violations found.


Review generated by /accelerator:review-pr

Comment thread .github/workflows/pr.yaml
ENCRYPTION_PASSPHRASE: ${{ secrets.ENCRYPTION_PASSPHRASE }}
- name: Configure RubyGems credentials
run: ./scripts/ci/common/configure-rubygems.sh
- name: Publish prerelease

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Security — plan concern (documented-deliberate, D8; not blocking)

This pull_request-triggered job checks out the PR branch and runs that branch's own scripts with ENCRYPTION_PASSPHRASE in scope, which decrypts the committed GPG key → unlocks git-crypt → yields the RubyGems push credentials. The if guard excludes forks and Dependabot but not a rogue/compromised same-repo contributor branch, and unlike release there is no environment/reviewer gate. The plan documents this as intended (pre-merge proof of the publish path). Worth a human deciding whether to gate the PR publish behind a reviewed environment or a push-only RubyGems key that rotates independently of the git-crypt passphrase.

Comment thread .github/workflows/pr.yaml
needs: [check, test, build]
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.pull_request.user.login == 'dependabot[bot]'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Security — plan concern (§1 parity, D3; not blocking)

The job auto-merges any Dependabot PR that passes checks, with no update-type restriction. A poisoned upstream release that still passes the unit/build checks would merge automatically (shipping with the next human-triggered release). Explicitly accepted as parity; post-migration hardening may add a steps.metadata.update-type allow-list.

Comment thread .github/workflows/pr.yaml
# --match-head-commit fails the merge if a commit landed after checks passed
# [skip ci] stops the merge commit triggering a release build
# PR title via env, never interpolated
run: gh pr merge --merge --match-head-commit "$HEAD_SHA" "$PR_URL" --subject "$PR_TITLE [skip ci]"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Correctness — plan concern (implements D3; flagged for human verification, not blocking)

For pull_request runs triggered by Dependabot, GitHub historically grants a read-only GITHUB_TOKEN and withholds secrets. This job relies on the job-level permissions: { contents: write, pull-requests: write } to elevate secrets.GITHUB_TOKEN for the gh pr merge call. Worth confirming against current GitHub Dependabot token behaviour that this reliably elevates on the pull_request event — if not, Dependabot PRs would silently never auto-merge (a functional regression). The mechanism itself is exactly what D3 prescribes.

- name: Set CI git author
run: ./go repository:set_ci_author
- name: Bump version
run: ./go "version:bump[pre]"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Safety — plan concern (§1 parity, D5 inherited hazard; not blocking)

./go release pushes the gem to RubyGems before the subsequent git push / git push --tags steps. If the push fails (e.g. main advanced during the run) the version is already published and irreversible, but no matching commit/tag exists in the repo, and the next run collides on the version. Inherited from the untouched ./go release logic and explicitly flagged as deliberate; a fleet-wide reorder is post-migration work.

Comment thread .rubocop.yml
- context
- shared_examples
- it
- define_repository_tasks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Standards — plan concern (consequence of plan-verbatim inline code; not blocking)

This adds define_repository_tasks to Metrics/BlockLength AllowedMethods because the Rakefile inlines the token/passphrase resolution inside the RakeGithub.define_repository_tasks block (the plan's verbatim §4.4 step-3 code). The sibling rake_dependencies migration instead extracts top-level helper methods and needs no exemption. Following the plan's prescribed inline shape is the deliberate choice; worth a human aligning the family on one convention.

@phelma
phelma marked this pull request as ready for review July 22, 2026 16:35
@phelma
phelma merged commit fe1c32d into main Jul 22, 2026
5 of 6 checks passed
@phelma
phelma deleted the gha-migration branch July 22, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant